/*global require, exports, console, MontageElement */

/**
 * @module "montage/ui/flow.reel"
 */
var Component = require("../component").Component,
    observeProperty = require("frb/observers").observeProperty,
    FlowBezierSpline = require("./flow-bezier-spline").FlowBezierSpline,
    RangeController = require("../../core/range-controller").RangeController;


var PARSE_MS_PATTERN = /^(\d+)ms$/,
    PARSE_SEC_PATTERN = /^(\d+)s$/;

/**
 * @class Flow
 * @extends Component
 */
var Flow = exports.Flow = Component.specialize( /** @lends Flow.prototype # */ {
    /**
     * @constructs Flow
     */
    constructor: {
        value: function Flow() {
            // The template has a binding from these visibleIndexes to
            // the frustum controller's visibleIndexes.  We manage the
            // array within the flow and use it also in the flow
            // translate composer.
            this._paths = [];
            this._visibleIndexes = [];
            this._needsClearVisibleIndexes = true;
            // Tracks the elastic scrolling offsets relative to their
            // corresponding non-elastic scrolling positions on their
            // FlowBezierSpline.
            this._slideOffsets = {};
            this.defineBinding("_numberOfIterations", {
                "<-": "contentController.content.length"
            });
            // dispatches handle_numberOfIterationsChange
            this.addOwnPropertyChangeListener("_numberOfIterations", this);
            window.addEventListener("resize", this, false);

            this._newVisibleIndexes =  [];
        }
    },

    /**
     * An optional component or element to place inside the camera's
     * perspective shared by the slides.
     *
     * This will likely be replaced with a template parameter in a future
     * version.
     */
    slotContent: {
        serializable: true,
        value: null
    },

    __flowTranslateComposer: {
        value: null
    },

    _flowTranslateComposer: {
        get: function () {
            return this.__flowTranslateComposer;
        },
        set: function (value) {
            if (this.__flowTranslateComposer) {
                this.__flowTranslateComposer.removeEventListener("translateStart", this, false);
                this.__flowTranslateComposer.removeEventListener("translateEnd", this, false);
            }
            this.__flowTranslateComposer = value;
            this.__flowTranslateComposer.addEventListener("translateStart", this, false);
            this.__flowTranslateComposer.addEventListener("translateEnd", this, false);
        }

    },

    __firstIteration: {
        value: null
    },

    _firstIteration: {
        get: function () {
            return this.__firstIteration;
        },
        set: function (value) {
            this.__firstIteration = value;
            this.needsDraw = true;
        }
    },

    handleTranslateStart: {
        value: function () {
            this.callDelegateMethod("didTranslateStart", this);
        }
    },

    handleTranslateEnd: {
        value: function () {
            this.callDelegateMethod("didTranslateEnd", this);
        }
    },

    _scrollingMode: {
        value: "linear"
    },

    _transform: {
        value: null
    },

    _transformCss: {
        value: null
    },

    _transformPerspective: {
        value: null
    },

    /**
     * One of "linear" or "drag".
     *
     * Drag mode is an experiment to preserve the dragged slide's
     * position relative to the gesture pointer.  Since this feature is
     * not yet ready, "linear" is the default.
     *
     * Used by the corresponding `FlowTranslateComposer` and
     * communicated by way of a binding to the property of the same
     * name.
     */
    scrollingMode: {
        serializable: true,
        get: function () {
            return this._scrollingMode;
        },
        set: function (value) {
            switch (value) {
                case "linear":
                case "drag":
                    this._scrollingMode = value;
                    break;
            }
        }
    },

    _linearScrollingVector: {
        value: [-300, 0]
    },

    /**
     * A constant 2d vector used to transform a drag vector into a
     * scroll vector, applicable only in the "linear"
     * `scrollingMode`.
     *
     * Used by the corresponding `FlowTranslateComposer` and
     * communicated by way of a binding to the property of the same
     * name.
     */
    linearScrollingVector: {
        seriazable: true,
        get: function () {
            return this._linearScrollingVector;
        },
        set: function (value) {
            this._linearScrollingVector = value;
        }
    },

    /**
     * The repetition that manages the slides.  The repetition is
     * instantiated and bound by the template.
     */
    _repetition: {
        value: null
    },

    /**
     * The amount of time in miliseconds after a gesture has ended until
     * the flow's scroll inertia disipates.
     */
    momentumDuration: {
        serializable: true,
        value: 650
    },

    _splinePaths: {
        value: null
    },

    // TODO doc
    /**
     * An internal representation of the paths that slides will follow.
     * The paths are taken from the serialization, transformed, and
     * stored here.  Each path is a `FlowBezierSpline`.
     * @private
     */
    splinePaths: {
        enumerable: false,
        get: function () {
            if (!this._splinePaths) {
                this._splinePaths = [];
            }
            return this._splinePaths;
        },
        set: function (value) {
            this._splinePaths = value;
        }
    },

    /**
     * Creates a `FlowBezierSpline` with data from a path in
     * the serialization and appends it to `splinePaths`
     * array.
     *
     * The public interface for modifying the paths of a
     * `Flow` is to set the `paths` propery.
     *
     * @private
     */
    _appendPath: {
        value: function (path) {
            var splinePath = new FlowBezierSpline(),
                pathKnots = path.knots,
                length = path.knots.length,
                knots = [],
                nextHandlers = [],
                previousHandlers = [],
                densities = [],
                i, j,
                splinePathParameters,
                pathUnits = path.units,
                units = pathUnits ? Object.keys(pathUnits) : void 0,
                unit,
                iPathKnot;

            splinePath._parameterKeys = units;
            splinePathParameters = splinePath.parameters = {};
            if(units) {
                for (i=0;(unit = units[i]);i++) {
                    splinePathParameters[unit] = {
                        data: [],
                        units: pathUnits[unit]
                    };
                }
            }
            for (i = 0; i < length; i++) {
                iPathKnot = pathKnots[i];
                knots[i] = iPathKnot.knotPosition;
                previousHandlers[i] = iPathKnot.previousHandlerPosition;
                nextHandlers[i] = iPathKnot.nextHandlerPosition;
                densities[i] = iPathKnot.previousDensity; // TODO: implement previous/next density
                for (j in pathUnits) {
                    if (pathUnits.hasOwnProperty(j)) {
                        splinePathParameters[j].data.push(iPathKnot[j]);   
                    }
                }
            }
            splinePath.knots = knots;
            splinePath.previousHandlers = previousHandlers;
            splinePath.nextHandlers = nextHandlers;
            splinePath.densities = densities;
            splinePath._computeDensitySummation();
            this.splinePaths.push(splinePath);
            if (!path.hasOwnProperty("headOffset")) {
                path.headOffset = 0;
            }
            if (!path.hasOwnProperty("tailOffset")) {
                path.tailOffset = 0;
            }
            this._paths.push(path);
            this._updateLength();
        }
    },

    _paths: {
        value: null
    },

    /**
     * The paths that slides will follow in the flow, as represented in
     * the serialization of a Flow.  Each path is an object with a
     * "knots" array, "headOffset", "tailOffset", and "units"
     * descriptor.  Each "knot" has "knotPosition",
     * "previousHandlerPosition", "previousDensity", and "nextDensity"
     * properties.  The positions are 3d vectors represented as arrays.
     * Densities are describe slide gravitation relative to other knots,
     * where equal densities represent linear distribution of slides
     * from knot to knot.
     *
     * The paths property is a getter and setter.  The Flow converts the
     * paths to and from an internal `splinePaths`
     * representation.
     */
    // TODO document the meaning of offsets
    paths: { // TODO: listen for changes?
        get: function () {
            var length = this.splinePaths.length,
                paths = [],
                path,
                pathLength,
                parametersLength,
                knot,
                i, j, k,
                iSplinePath;

            for (i = 0; (iSplinePath = this.splinePaths[i]); i++) {
                pathLength = iSplinePath.knots.length;
                path = {
                    knots: [],
                    units: {}
                };
                for (j = 0; j < pathLength; j++) {
                    knot = {
                        knotPosition: iSplinePath.knots[j]
                    };
                    if (iSplinePath.nextHandlers && iSplinePath.nextHandlers[j]) {
                        knot.nextHandlerPosition = iSplinePath.nextHandlers[j];
                    }
                    if (iSplinePath.previousHandlers && iSplinePath.previousHandlers[j]) {
                        knot.previousHandlerPosition = iSplinePath.previousHandlers[j];
                    }
                    // TODO implememnt previous/next densities
                    if (iSplinePath.densities && iSplinePath.densities[j]) {
                        knot.previousDensity = iSplinePath.densities[j];
                        knot.nextDensity = iSplinePath.densities[j];
                    }
                    path.knots.push(knot);
                }
                for (j in iSplinePath.parameters) {
                    if (iSplinePath.parameters.hasOwnProperty(j)) {
                        path.units[j] = iSplinePath.parameters[j].units;
                        parametersLength = iSplinePath.parameters[j].data.length;
                        for (k = 0; k < parametersLength; k++) {
                            path.knots[k][j] = iSplinePath.parameters[j].data[k];
                        }   
                    }
                }
                if (this._paths[i].hasOwnProperty("headOffset")) {
                    path.headOffset = this._paths[i].headOffset;
                } else {
                    path.headOffset = 0;
                }
                if (this._paths[i].hasOwnProperty("tailOffset")) {
                    path.tailOffset = this._paths[i].tailOffset;
                } else {
                    path.tailOffset = 0;
                }
                paths.push(path);
            }
            return paths;
        },
        set: function (value) {
            var length = value.length,
                i;

            this._splinePaths = [];
            this._paths = [];
            for (i = 0; i < length; i++) {
                this._appendPath(value[i]);
            }
            this.needsDraw = true;
            this._needsComputeVisibleRange = true;
        }
    },

    _isCameraEnabled: {
        value: true
    },

    isCameraEnabled: {
        get: function () {
            return this._isCameraEnabled;
        },
        set: function (value) {
            var enabled = !!value;

            if (this._isCameraEnabled !== enabled) {
                this._isCameraEnabled = enabled;
                this._isCameraUpdated = true;
                this._needsComputeVisibleRange = true;
                this.needsDraw = true;
            }
        }
    },

    /**
     * CSS perspective value in pixels used when camera is disabled
     */
    _perspective: {
        value: 500
    },

    perspective: {
        get: function () {
            return this._perspective;
        },
        set: function (value) {
            var perspective = parseFloat(value);

            if (!isNaN(perspective) && (this._perspective !== perspective)) {
                this._perspective = perspective;
                this._isCameraUpdated = true;
                this._needsComputeVisibleRange = true;
                this.needsDraw = true;
            }
        }
    },

    /**
     * The camera elements is the DOM element that contains the
     * repetition and on which the flow applies 3d transforms.
     */
    _cameraElement: {
        value: null
    },

    _cameraPosition: {
        value: [0, 0, 800]
    },

    /**
     * An [x, y, z] array representing the 3d vector describing the
     * position of the virtual camera.
     */
    cameraPosition: {
        get: function () {
            return this._cameraPosition;
        },
        set: function (value) {
            this._cameraPosition = value;
            this._isCameraUpdated = true;
            this.needsDraw = true;
            this._needsComputeVisibleRange = true;
        }
    },

    _viewpointPosition: {
        get: function () {
            if (this._isCameraEnabled) {
                return this.cameraPosition;
            } else {
                return [
                     (50 - this._sceneOffsetLeft) * 0.01 * this._width,
                     (50 - this._sceneOffsetTop) * 0.01 * this._height,
                     this._perspective
                ];
            }
        }
    },

    _cameraTargetPoint: {
        value: [0, 0, 0]
    },

    /**
     * An [x, y, z] array representing the 3d vector describing the
     * position of one of the points in the center of the camera's view.
     */
    cameraTargetPoint: {
        get: function () {
            return this._cameraTargetPoint;
        },
        set: function (value) {
            this._cameraTargetPoint = value;
            this._isCameraUpdated = true;
            this.needsDraw = true;
            this._needsComputeVisibleRange = true;
        }
    },

    _viewpointTargetPoint: {
        get: function () {
            if (this._isCameraEnabled) {
                return this.cameraTargetPoint;
            } else {
                return [
                    (50 - this._sceneOffsetLeft) * 0.01 * this._width,
                    (50 - this._sceneOffsetTop) * 0.01 * this._height,
                    0
                ];
            }
        }
    },

    _cameraFov: {
        value: 50
    },

    /**
     * The "field of view" of the camera, measured in degrees away from
     * the ray from the camera position to the camera target point.
     */
    cameraFov: {
        get: function () {
            return this._cameraFov;
        },
        set: function (value) {
            this._cameraFov = value;
            this._isCameraUpdated = true;
            this.needsDraw = true;
            this._needsComputeVisibleRange = true;
        }
    },

    _viewpointFov: {
        get: function () {
            if (this._isCameraEnabled) {
                return this.cameraFov;
            } else {
                return ((Math.PI / 2) - Math.atan2(this._perspective, this._height / 2)) * 360 / Math.PI;
            }
        }
    },

    // TODO: Implement camera roll

    _cameraRoll: {
        value: 0
    },

    /**
     * The angle that the camera is rotated about the ray from the
     * camera position to the camera target point, away from vertical.
     *
     * This feature is not presently implemented.
     */
    cameraRoll: {
        get: function () {
            return this._cameraRoll;
        },
        set: function (value) {
            this._cameraRoll = value;
            this._isCameraUpdated = true;
            this.needsDraw = true;
            this._needsComputeVisibleRange = true;
        }
    },

    /**
     * Scene's vertical offset relative to the viewport.
     * Expressed as a percentage of the viewport's height.
     * Only in use when camera is disabled.
     */
    _sceneOffsetTop: {
        value: 50
    },

    sceneOffsetTop: {
        get: function () {
            return this._sceneOffsetTop;
        },
        set: function (value) {
            this._sceneOffsetTop = value;
            this._isCameraUpdated = true;
            this.needsDraw = true;
            this._needsComputeVisibleRange = true;
        }
    },

    /**
     * Scene's horizontal offset relative to the viewport.
     * Expressed as a percentage of the viewport's width.
     * Only in use when camera is disabled.
     */
    _sceneOffsetLeft: {
        value: 50
    },

    sceneOffsetLeft: {
        get: function () {
            return this._sceneOffsetLeft;
        },
        set: function (value) {
            this._sceneOffsetLeft = value;
            this._isCameraUpdated = true;
            this.needsDraw = true;
            this._needsComputeVisibleRange = true;
        }
    },

    /**
     * Scene's scale for each axis x/y/z
     * Expressed as an object representing a fraction with numerator and denominator properties
     * Defaults as 1:1, original size
     */
    _sceneScaleX: {
        value: {
            numerator: 1,
            denominator: 1
        }
    },

    _sceneScaleY: {
        value: {
            numerator: 1,
            denominator: 1
        }
    },

    _sceneScaleZ: {
        value: {
            numerator: 1,
            denominator: 1
        }
    },

    _sceneScale: {
        value: {
            x: {numerator: 1, denominator: 1},
            y: {numerator: 1, denominator: 1},
            z: {numerator: 1, denominator: 1}
        },
    },

    _updateSceneScale: {
        value: function () {
            this._sceneScale = {
                x: this._sceneScaleX,
                y: this._sceneScaleY,
                z: this._sceneScaleZ
            };
        }
    },

    sceneScaleX: {
        get: function () {
            return this._sceneScaleX;
        },
        set: function (value) {
            if ((typeof value === "object") &&
                (typeof value.numerator !== "undefined") &&
                (typeof value.denominator !== "undefined") &&
                (!isNaN(value.numerator)) &&
                (!isNaN(value.denominator)) &&
                (value.denominator !== 0)) {
                this._sceneScaleX = value;
                this._updateSceneScale();
                this.needsDraw = true;
                this._needsComputeVisibleRange = true;
            }
        }
    },

    sceneScaleY: {
        get: function () {
            return this._sceneScaleY;
        },
        set: function (value) {
            if ((typeof value === "object") &&
                (typeof value.numerator !== "undefined") &&
                (typeof value.denominator !== "undefined") &&
                (!isNaN(value.numerator)) &&
                (!isNaN(value.denominator)) &&
                (value.denominator !== 0)) {
                this._sceneScaleY = value;
                this._updateSceneScale();
                this.needsDraw = true;
                this._needsComputeVisibleRange = true;
            }
        }
    },

    sceneScaleZ: {
        get: function () {
            return this._sceneScaleZ;
        },
        set: function (value) {
            if ((typeof value === "object") &&
                (typeof value.numerator !== "undefined") &&
                (typeof value.denominator !== "undefined") &&
                (!isNaN(value.numerator)) &&
                (!isNaN(value.denominator)) &&
                (value.denominator !== 0)) {
                this._sceneScaleZ = value;
                this._updateSceneScale();
                this.needsDraw = true;
                this._needsComputeVisibleRange = true;
            }
        }
    },

    _stride: {
        value: 0
    },

    /**
     * The interval between snap points along the paths, to which slides
     * wil gravitate.  The default value of "0" disables this feature.
     */
    stride: {
        get: function () {
            return this._stride;
        },
        set: function (value) {
            this._stride = value;
        }
    },

    /**
     * An internal cache of `scrollingTransitionDuration` in
     * units of miliseconds.
     */
    _scrollingTransitionDurationMiliseconds: {
        value: 500
    },

    /**
     * An internal cache of `scrollingTransitionDuration` as
     * a string suitable for CSS.
     */
    _scrollingTransitionDuration: {
        value: "500ms"
    },

    /**
     * The configurable duration of scrolling animation in miliseconds.
     * It may be set to a number, or a CSS duration like "1s" or
     * "500ms".  Getting the property always reports the number.  The
     * default is 500.
     */
    scrollingTransitionDuration: { // TODO: think about using the Date Converter
        get: function () {
            return this._scrollingTransitionDuration;
        },
        set: function (duration) {
            var durationString = duration + "",
                length = durationString.length,
                value,
                match;

            // TODO parseInt ?
            if ((match = PARSE_MS_PATTERN.exec(durationString))) {
                value = +match[1];
            } else if ((match = PARSE_SEC_PATTERN.exec(durationString))) {
                value = +match[1] * 1000;
            } else {
                value = +durationString;
                durationString += "ms";
            }
            if (!isNaN(value) && (this._scrollingTransitionDurationMiliseconds !== value)) {
                this._scrollingTransitionDurationMiliseconds = value;
                this._scrollingTransitionDuration = durationString;
            }
        }
    },

    // TODO doc
    /**
     */
    hasSelectedIndexScrolling: {
        value: false
    },

    // TODO doc
    /**
     */
    selectedIndexScrollingOffset: {
        value: 0
    },

    // TODO doc
    /**
     */
    _handleSelectedIndexesChange: {
        value: function (plus, minus, index) {
            if (this.hasSelectedIndexScrolling && plus[0]) {
                this.startScrollingIndexToOffset(
                    Math.floor(this.contentController.content.indexOf(plus[0].object) / this._paths.length),
                    this.selectedIndexScrollingOffset
                );
            }
        }
    },

    /**
     * Internal lookup table of CSS timing functions to their
     * corresponding cubic bezier parameters.  This is used by the
     * `scrollingTransitionTimingFunction` setter to
     * translate <em>named</em> transitions like "ease" to their
     * corresponding cubic bezier internal representation.
     */
    _timingFunctions: {
        value: {
            "ease": [0.25, 0.1, 0.25, 1],
            "linear": [0, 0, 1, 1],
            "ease-in": [0.42, 0, 1, 1],
            "ease-out": [0, 0, 0.58, 1],
            "ease-in-out": [0.42, 0, 0.58, 1]
        }
    },

    /**
     * Internal representation of the CSS timing function as an array of
     * numbers for scroll transitions, the format of CSS timing
     * functions.
     *
     * This is produced by setting
     * `scrollingTransitionTimingFunction`. Note the absence
     * of the `Bezier` qualifier.
     */
    _scrollingTransitionTimingFunctionBezier: {
        value: [0.25, 0.1, 0.25, 1]
    },

    /**
     * Internal cache of the transition timing function.
     */
    _scrollingTransitionTimingFunction: {
        value: "ease"
    },

    /**
     * The CSS timing function, "ease" by default, used for smooth
     * scroll transitions.  Supports named timing functions "ease",
     * "linear", "ease-in", "ease-out", "ease-in-out", and the tuple
     * `cubic-bezier(0, 0, 1, 1)` format as well.
     */
    scrollingTransitionTimingFunction: {
        get: function () {
            return this._scrollingTransitionTimingFunction;
        },
        set: function (timingFunction) {
            var string = timingFunction + "",
                bezier,
                i;

            if (this._timingFunctions.hasOwnProperty(string)) {
                this._scrollingTransitionTimingFunction = string;
                this._scrollingTransitionTimingFunctionBezier = this._timingFunctions[string];
            } else {
                if ((string.substr(0, 13) === "cubic-bezier(") && (string.substr(string.length - 1, 1) === ")")) {
                    bezier = string.substr(13, string.length - 14).split(",");
                    if (bezier.length === 4) {
                        for (i = 0; i < 4; i++) {
                            bezier[i] -= 0;
                            if (isNaN(bezier[i])) {
                                return;
                            }
                        }
                        if (bezier[0] < 0) {
                            bezier[0] = 0;
                        } else {
                            if (bezier[0] > 1) {
                                bezier[0] = 1;
                            }
                        }
                        if (bezier[2] < 0) {
                            bezier[2] = 0;
                        } else {
                            if (bezier[2] > 1) {
                                bezier[2] = 1;
                            }
                        }
                        // TODO: check it is not the same bezier
                        this._scrollingTransitionTimingFunction = "cubic-bezier(" + bezier + ")";
                        this._scrollingTransitionTimingFunctionBezier = bezier;
                    }
                }
            }
        }
    },

    /**
     * Used in scrolling transitions to compute the interpolation values
     * in a cubic bezier curve in the same way as CSS transitions.
     */
    _computeCssCubicBezierValue: {
        value: function (x, bezier) {
            var t = 0.5,
                step = 0.25,
                t2,
                k,
                i;

            for (i = 0; i < 20; i++) { // TODO: optimize with Newton's method or similar
                t2 = t * t;
                k = 1 - t;
                if ((3 * (k * k * t * bezier[0] + k * t2 * bezier[2]) + t2 * t) > x) {
                    t -= step;
                } else {
                    t += step;
                }
                step *= 0.5;
            }
            t2 = t * t;
            k = 1 - t;
            return 3 * (k * k * t * bezier[1] + k * t2 * bezier[3]) + t2 * t;
        }
    },

    /**
     * A flag that indicates that scrolling animation is in progress.
     */
    _isTransitioningScroll: {
        value: false
    },

    /**
     * Stops scrolling animation.
     */
    stopScrolling: {
        value: function () {
            this._isTransitioningScroll = false;
            // TODO: Fire scrollingTransitionCancel event
        }
    },

    /**
     * Starts an scrolling animation from the given slide index to the
     * given offset position.
     */
    // TODO document the range of slide indexes and what they correspond
    // to, relative to the actual content, organized content, visible
    // content, or order on the document
    // TODO document the units of the offset position
    startScrollingIndexToOffset: { // TODO: Fire scrollingTransitionStart event
        value: function (index, offset) {
            this._scrollingOrigin = this.scroll;
            this._scrollingDestination = index - offset;
            if (this._scrollingDestination > this._length) {
                this._scrollingDestination = this._length;
            } else {
                if (this._scrollingDestination < 0) {
                    this._scrollingDestination = 0;
                }
            }
            this._isScrolling = true;
            this._scrollingStartTime = Date.now();
            this._isTransitioningScroll = true;
            this.needsDraw = true;
            this.callDelegateMethod("didTranslateStart", this);
        }
    },

    /**
     * A flag that informs the `draw` method that the camera
     * properties were changed.
     */
    _isCameraUpdated: {
        value: true
    },

    // TODO doc
    /**
     */
    _width: {
        value: 0
    },

    // TODO doc
    /**
     */
    _height: {
        value: 0
    },

    // TODO: bounding box is working as bounding rectangle only. Update it to work with boxes
    // TODO doc
    /**
     */
    _boundingBoxSize: {
        value: null
    },

    // TODO doc
    /**
     */
    boundingBoxSize: {
        serializable: true,
        get: function () {
            return this._boundingBoxSize;
        },
        set: function (value) {
            this._boundingBoxSize = value;
            this.elementsBoundingSphereRadius = Math.sqrt(value[0] * value[0] + value[1] * value[1] + value[2] * value[2]) * 0.5;
            this._needsComputeVisibleRange = true;
        }
    },

    // TODO doc
    /**
     */
    _elementsBoundingSphereRadius: {
        value: 1
    },

    // TODO doc
    /**
     */
    elementsBoundingSphereRadius: {
        get: function () {
            return this._elementsBoundingSphereRadius;
        },
        set: function (value) {
            if (this._elementsBoundingSphereRadius !== value) {
                this._elementsBoundingSphereRadius = value;
                this.needsDraw = true;
                this._needsComputeVisibleRange = true;
            }
        }
    },

    _halfPI: {
        value: Math.PI * 0.5
    },

    _doublePI: {
        value: Math.PI * 2
    },

    // TODO doc
    /**
     */
    _computeFrustumNormals: {
        value: function () {
            var angle = ((this._viewpointFov * 0.5) * this._doublePI) / 360,
                y = Math.sin(angle),
                z = Math.cos(angle),
                x = (y * this._width) / this._height,
                viewpointPosition = this._viewpointPosition,
                viewpointTargetPoint = this._viewpointTargetPoint,
                vX = viewpointTargetPoint[0] - viewpointPosition[0],
                vY = viewpointTargetPoint[1] - viewpointPosition[1],
                vZ = viewpointTargetPoint[2] - viewpointPosition[2],
                yAngle = this._halfPI - Math.atan2(vZ, vX),
                tmpZ = vX * Math.sin(yAngle) + vZ * Math.cos(yAngle),
                rX, rY, rZ,
                rX2, rY2, rZ2,
                xAngle = this._halfPI - Math.atan2(tmpZ, vY),
                invLength,
                vectors = [[z, 0, x], [-z, 0, x], [0, z, y], [0, -z, y]],
                iVector,
                out = [],
                i;

            for (i = 0; i < 4; i++) {
                iVector = vectors[i];
                rX = iVector[0];
                rY = iVector[1] * Math.cos(-xAngle) - iVector[2] * Math.sin(-xAngle);
                rZ = iVector[1] * Math.sin(-xAngle) + iVector[2] * Math.cos(-xAngle);
                rX2 = rX * Math.cos(-yAngle) - rZ * Math.sin(-yAngle);
                rY2 = rY;
                rZ2 = rX * Math.sin(-yAngle) + rZ * Math.cos(-yAngle);
                invLength = 1 / Math.sqrt(rX2 * rX2 + rY2 * rY2 + rZ2 * rZ2);
                out.push([rX2 * invLength, rY2 * invLength, rZ2 * invLength]);
            }
            return out;
        }
    },

    // TODO doc
    /**
     */
    _segmentsIntersection: {
        value: function (segment1, segment2) {
            var n = 0,
                m = 0,
                start,
                end,
                result = [];

            while ((n < segment1.length) && (m < segment2.length)) {
                if (segment1[n][0] >= segment2[m][1]) {
                    m++;
                } else {
                    if (segment1[n][1] <= segment2[m][0]) {
                        n++;
                    } else {
                        if (segment1[n][0] >= segment2[m][0]) {
                            start = segment1[n][0];
                        } else {
                            start = segment2[m][0];
                        }
                        if (segment1[n][1] <= segment2[m][1]) {
                            end = segment1[n][1];
                        } else {
                            end = segment2[m][1];
                        }
                        result.push([start, end]);
                        if (segment1[n][1] < segment2[m][1]) {
                            n++;
                        } else {
                            if (segment1[n][1] > segment2[m][1]) {
                                m++;
                            } else {
                                n++;
                                m++;
                            }
                        }
                    }
                }
            }
            return result;
        }
    },

    // TODO doc
    /**
     */
    _computeVisibleRange: { // TODO: make it a loop, optimize
        value: function (spline) {
            var splineLength = spline._knots.length - 1,
                viewpointPosition = this._viewpointPosition,
                planeOrigin0 = viewpointPosition[0],
                planeOrigin1 = viewpointPosition[1],
                planeOrigin2 = viewpointPosition[2],
                normals = this._computeFrustumNormals(),
                mod,
                r = [], r2 = [], r3 = [], tmp,
                i, j,
                elementsBoundingSphereRadius = this._elementsBoundingSphereRadius,
                splineKnots = spline.getScaledKnots(this._sceneScale),
                splineNextHandlers = spline.getScaledNextHandlers(this._sceneScale),
                splinePreviousHandlers = spline.getScaledPreviousHandlers(this._sceneScale),
                out = [];

            for (i = 0; i < splineLength; i++) {
                mod = normals[0];
                r = spline.directedPlaneBezierIntersection(
                        planeOrigin0 - mod[0] * elementsBoundingSphereRadius,
                        planeOrigin1 - mod[1] * elementsBoundingSphereRadius,
                        planeOrigin2 - mod[2] * elementsBoundingSphereRadius,
                    normals[0],
                    splineKnots[i],
                    splineNextHandlers[i],
                    splinePreviousHandlers[i + 1],
                    splineKnots[i + 1]
                );
                if (r.length) {
                    mod = normals[1];
                    r2 = spline.directedPlaneBezierIntersection(
                            planeOrigin0 - mod[0] * elementsBoundingSphereRadius,
                            planeOrigin1 - mod[1] * elementsBoundingSphereRadius,
                            planeOrigin2 - mod[2] * elementsBoundingSphereRadius,
                        normals[1],
                        splineKnots[i],
                        splineNextHandlers[i],
                        splinePreviousHandlers[i + 1],
                        splineKnots[i + 1]
                    );
                    if (r2.length) {
                        tmp = this._segmentsIntersection(r, r2);
                        if (tmp.length) {
                            mod = normals[2];
                            r = spline.directedPlaneBezierIntersection(
                                    planeOrigin0 - mod[0] * elementsBoundingSphereRadius,
                                    planeOrigin1 - mod[1] * elementsBoundingSphereRadius,
                                    planeOrigin2 - mod[2] * elementsBoundingSphereRadius,
                                normals[2],
                                splineKnots[i],
                                splineNextHandlers[i],
                                splinePreviousHandlers[i + 1],
                                splineKnots[i + 1]
                            );
                            tmp = this._segmentsIntersection(r, tmp);
                            if (tmp.length) {
                                mod = normals[3];
                                r = spline.directedPlaneBezierIntersection(
                                        planeOrigin0 - mod[0] * elementsBoundingSphereRadius,
                                        planeOrigin1 - mod[1] * elementsBoundingSphereRadius,
                                        planeOrigin2 - mod[2] * elementsBoundingSphereRadius,
                                    normals[3],
                                    splineKnots[i],
                                    splineNextHandlers[i],
                                    splinePreviousHandlers[i + 1],
                                    splineKnots[i + 1]
                                );
                                tmp = this._segmentsIntersection(r, tmp);
                                for (j = 0; j < tmp.length; j++) {
                                    r3.push([i, tmp[j][0], tmp[j][1]]);
                                }

                            }
                        }
                    }
                }
            }
            var densities = spline._densities, d1, d2, dS, p1, p2, t1, t2;
            for (i = 0; i < r3.length; i++) {
                d1 = densities[r3[i][0]];
                d2 = densities[r3[i][0] + 1];
                dS = r3[i][0] ? spline._densitySummation[r3[i][0]-1] : 0;
                p1 = r3[i][1];
                p2 = r3[i][2];
                t1 = (d2 - d1) * p1 * p1 * 0.5 + p1 * d1 + dS;
                t2 = (d2 - d1) * p2 * p2 * 0.5 + p2 * d1 + dS;
                out.push([t1, t2]);
            }
            return out;
        }
    },

    _determineCssPrefixedProperties: {
        value: function () {
            if("webkitTransform" in this.element.style) {
                this._transform = "webkitTransform";
                this._transformCss = "-webkit-transform";
                this._transformPerspective = "webkitPerspective";
            } else if("MozTransform" in this.element.style) {
                this._transform = "MozTransform";
                this._transformCss = "-moz-transform";
                this._transformPerspective = "MozPerspective";
            } else if("msTransform" in this.element.style) {
                this._transform = "msTransform";
                this._transformCss = "-ms-transform";
                this._transformPerspective = "msPerspective";
            } else {
                this._transform = "transform";
                this._transformPerspective = "perspective";
            }
        }
    },

    _isListeningToResize: {
        value: true
    },

    isListeningToResize: {
        get: function () {
            return this._isListeningToResize;
        },
        set: function (value) {
            var _value = !!value;

            if (this._isListeningToResize !== _value) {
                this._isListeningToResize = _value;
                if (this._isListeningToResize) {
                    window.addEventListener("resize", this, false);
                } else {
                    window.removeEventListener("resize", this, false);
                }
            }
        }
    },

    _needsClearVisibleIndexes: {
        value: false
    },

    handleResize: {
        value: function () {
            this._isCameraUpdated = true;
            this._needsComputeVisibleRange = true;
            this.needsDraw = true;
            this._needsClearVisibleIndexes = true;
        }
    },

    enterDocument: {
        value: function (firstTime) {
            if (firstTime) {
                var self = this;
                this._determineCssPrefixedProperties();
                this._repetition.addRangeAtPathChangeListener("selectedIterations", this, "_handleSelectedIndexesChange");
                // TODO remove event listener
            }
        }
    },

    /**
     * In order to prevent jitter and minimize thrashing on the DOM, the Flow
     * attempts to reuse every iteration, favoring moving them around with CSS
     * transforms over moving them around on the document, and favoring
     * rebinding over removing an iteration that is leaving and injecting an
     * iteration for content entering.
     *
     * To those ends, this algorithm takes the idealized array of new visible
     * indexes, as computed by willDraw, and transforms the current visible
     * indexes array without moving any content that is in both
     * the old and new arrays.  Instead, it finds all of the positions that
     * have content that is leaving the Flow, and fills those "holes" with
     * content entering the Flow.
     *
     * To accomplish this, the algorithm takes as input the
     * `newVisibleIndexes` and its inverse-lookup table,
     * `newContentIndexes`.  It uses the content indexes so that it
     * can triangulate whether the content at any particular visible index will
     * be retained in the new visible indexes at any position.  Otherwise,
     * there will be a hole at that index.
     *
     * Conceptually there are two domains of indexes: content indexes and
     * visible indexes.  The visible indexes correspond to positions within the
     * repetition.  The content indexes correspond to where the content exists
     * within the backing organized content array.  There are both new and old
     * forms of both indexes.  We use the <em>new, visible</em> indexes, the
     * <em>new, content</em> indexes, and the <em>old, content` indexes
     * to triangulate.
     *
     * <pre>
     * OVI -> OCI
     *  ^      v  ?
     * NVI    NCI
     * </pre>
     */
    _updateVisibleIndexes: {
        value: function (newVisibleIndexes, newContentIndexes) {
            var oldVisibleIndexes = this._visibleIndexes,
                oldIndexesLength = oldVisibleIndexes && !isNaN(oldVisibleIndexes.length) ? oldVisibleIndexes.length : 0,
                holes,
                j,
                i;

            if (this._needsClearVisibleIndexes) {
                this._visibleIndexes.splice(newVisibleIndexes.length, Infinity);
                this._needsClearVisibleIndexes = false;
            }

            // Search for viable holes, leave content at the same visible index
            // whenever possible.
            for (i = 0; i < oldIndexesLength; i++) {
                // # Legend
                // _: the previously defined expression in this legend
                // oldVisibleIndexes[i]: the index of the content that was at
                // visible index "i".
                // newContentIndexes[_]: the position that the content should
                // be in now, or undefined if the content is no longer visible.
                // newVisibleIndexes[_]: knowing that the content index that
                // was at visible index "i" in oldVisibleIndexes is now at
                // index "_" in newVisibleIndexes, set this to null as a
                // sentinel indicating "no change in position"

                // The likelyhood that newContentIndexes had a
                // number-turned-to-string property that wasn't his own is
                // pretty slim as it's provided internally.
                // if (newContentIndexes.hasOwnProperty(oldVisibleIndexes[i])) {
                if (typeof newContentIndexes[oldVisibleIndexes[i]] === "number") {
                    newVisibleIndexes[newContentIndexes[oldVisibleIndexes[i]]] = null;
                } else {
                    (holes || (holes = [])).push(i);
                }
            }

            // Fill the holes
            if(holes) {
                for (i = j = 0; (j < holes.length) && (i < newVisibleIndexes.length); i++) {
                    if (newVisibleIndexes[i] !== null) {
                        oldVisibleIndexes.set(holes[j], newVisibleIndexes[i]);
                        j++;
                    }
                }
            }
            // Add new values to the end if the visible indexes have grown
            for (j = oldIndexesLength; i < newVisibleIndexes.length; i++) {
                if (newVisibleIndexes[i] !== null) {
                    oldVisibleIndexes.set(j, newVisibleIndexes[i]);
                    j++;
                }
            }
            // Don't bother triming the excess. We just make them invisible and
            // leave them on the origin.
        }
    },

    _needsComputeVisibleRange: {
        value: true
    },

    _previousVisibleRanges: {
        value: null
    },

    viewportWidth: {
        get: function () {
            return this._width;
        },
        set: function (value) {
            if (this._width !== value) {
                this._width = value;
                this._needsComputeVisibleRange = true;
            }
        }
    },

    viewportHeight: {
        get: function () {
            return this._height;
        },
        set: function (value) {
            if (this._height !== value) {
                this._height = value;
                this._needsComputeVisibleRange = true;
            }
        }
    },

    _firstIterationWidth: {
        value: 1
    },

    _firstIterationHeight: {
        value: 1
    },

    firstIterationWidth: {
        get: function () {
            return this._firstIterationWidth;
        },
        set: function (value) {
            if (value !== this._firstIterationWidth) {
                this._firstIterationWidth = value;
                this._needsComputeVisibleRange = true;
                this._needsClearVisibleIndexes = true;
            }
        }
    },

    firstIterationHeight: {
        get: function () {
            return this._firstIterationHeight;
        },
        set: function (value) {
            if (value !== this._firstIterationHeight) {
                this._firstIterationHeight = value;
                this._needsComputeVisibleRange = true;
                this._needsClearVisibleIndexes = true;
            }
        }
    },

    _firstIterationOffsetLeft: {
        value: 0
    },

    _firstIterationOffsetTop: {
        value: 0
    },

    willDraw: {
        value: function Flow_willDraw(timestamp) {
            var intersections,
                index,
                i,
                countI,
                j,
                k,
                offset,
                startIndex,
                endIndex,
                mod,
                div,
                iterations,
                newVisibleIndexes = this._newVisibleIndexes,
                // newContentIndexes is a reverse-lookup hash of
                // newVisibleIndexes, which we keep in sync manually.
                newContentIndexes = {},
                time,
                interpolant,
                paths = this._paths,
                pathsLength = paths.length,
                splinePaths = this.splinePaths;

            this.viewportWidth = this._element.clientWidth;
            this.viewportHeight = this._element.clientHeight;
            if (this.__firstIteration) {
                var element = this.__firstIteration.firstElement.children[0];

                if ((element.offsetWidth !== 0) && (element.offsetHeight !== 0)) {
                    this.firstIterationWidth = element.offsetWidth;
                    this.firstIterationHeight = element.offsetHeight;
                    this._firstIterationOffsetLeft = element.offsetLeft;
                    this._firstIterationOffsetTop = element.offsetTop;
                    if (!this._boundingBoxSize) {
                        var x = Math.max(
                                Math.abs(this._firstIterationWidth + this._firstIterationOffsetLeft),
                                Math.abs(this._firstIterationOffsetLeft)
                            ),
                            y = Math.max(
                                Math.abs(this._firstIterationHeight + this._firstIterationOffsetTop),
                                Math.abs(this._firstIterationOffsetTop)
                            );

                        this._elementsBoundingSphereRadius = Math.sqrt(x * x + y * y);
                    }
                }
            }
            // Manage scroll animation
            if (this._isTransitioningScroll) {
                time = (Date.now() - this._scrollingStartTime) / this._scrollingTransitionDurationMiliseconds; // TODO: division by zero
                interpolant = this._computeCssCubicBezierValue(time, this._scrollingTransitionTimingFunctionBezier);
                if (time < 1) {
                    this.scroll = this._scrollingOrigin + (this._scrollingDestination - this._scrollingOrigin) * interpolant;
                } else {
                    this.scroll = this._scrollingDestination;
                    this._isTransitioningScroll = false;
                    this._needsToCallDidTranslateEndDelegate = true;
                }
            }


            // "iterations" is the number of iterations for the numerical methods
            // integration of elastic scrolling. The higher the iterations, the more
            // precise it is, but slower to compute. Setting it to 6 provides
            // a good balance between precision and performance.
            time = timestamp;
            iterations = 6;
            
            var interval1 = this.lastDrawTime ? (time - this.lastDrawTime) * 0.018 * this._elasticScrollingSpeed : 0,
                interval = 1 - (interval1 / iterations),
                offset1, offset2, resultOffset,
                min = this._minSlideOffsetIndex,
                max = this._maxSlideOffsetIndex,
                position,
                step;

            this.lastDrawTime = time;
            if (this._hasElasticScrolling) {
                for (j = 0; j < iterations; j++) {
                    for (i = this._draggedSlideIndex - 1; i >= min; i--) {
                        offset1 = this._getSlideOffset(i);
                        offset2 = this._getSlideOffset(i + 1);
                        resultOffset = (offset1 - offset2) * interval + offset2;
                        if (resultOffset > 0) {
                            resultOffset = 0;
                        }
                        this._updateSlideOffset(i, resultOffset);
                    }
                    for (i = this._draggedSlideIndex + 1; i <= max; i++) {
                        offset1 = this._getSlideOffset(i);
                        offset2 = this._getSlideOffset(i - 1);
                        resultOffset = (offset1 - offset2) * interval + offset2;
                        if (resultOffset < 0) {
                            resultOffset = 0;
                        }
                        this._updateSlideOffset(i, resultOffset);
                    }
                }
            }

            // Compute which slides are in view
            if (splinePaths.length) {
                mod = this._numberOfIterations % pathsLength;
                div = (this._numberOfIterations - mod) / pathsLength;
                if (this._needsComputeVisibleRange) {
                    this._previousVisibleRanges = [];
                }
                for (k = 0; k < pathsLength; k++) {
                    iterations = div + ((k < mod) ? 1 : 0);
                    if (this._needsComputeVisibleRange) {
                        intersections = this._computeVisibleRange(splinePaths[k]);
                        this._previousVisibleRanges[k] = intersections;
                        splinePaths[k]._computeDensitySummation();
                    } else {
                        intersections = this._previousVisibleRanges[k];
                    }
                    offset =  this._scroll - paths[k].headOffset;
                    for (i = 0, countI = intersections.length; i < countI; i++) {
                        step = iterations / 2;
                        j = step;
                        while (step >= 1) {
                            index = (j|0) * pathsLength + k;
                            position = (j|0) + this._getSlideOffset(index) - offset;
                            step /= 2;
                            if (position >= intersections[i][0]) {
                                j -= step;
                            } else {
                                j += step;
                            }
                        }
                        j = (j - 1) | 0;
                        if (j < 0) {
                            j = 0;
                        }
                        do {
                            index = j * pathsLength + k;
                            position = j + this._getSlideOffset(index) - offset;
                            if ((position >= intersections[i][0]) && (position <= intersections[i][1]) && ( newContentIndexes[index] === void 0)) {
                                newContentIndexes[index] = newVisibleIndexes.length;
                                newVisibleIndexes[newVisibleIndexes.length] = index;
                            }
                            j++;
                        } while ((position <= intersections[i][1]) && (j < iterations));
                    }
                }
                this._needsComputeVisibleRange = false;
            }
            this._updateVisibleIndexes(newVisibleIndexes, newContentIndexes);
            newVisibleIndexes.length = 0;
        }
    },

    draw: {
        value: function (timestamp) {
            var i,
                length = this._repetition._drawnIterations.length,
                iteration,
                element,
                elementChildren,
                pos,
                perspective,
                visibleIndexes = this._visibleIndexes,
                viewpointPosition = this._viewpointPosition,
                viewpointTargetPoint = this._viewpointTargetPoint,
                indexTime,
                rotation,
                offset,
                _splinePaths = this._splinePaths,
                iSplinePath,
                cssText;

            if (this._isTransitioningScroll) {
                this.needsDraw = true;
            }
            if (this._isCameraUpdated) {
                if (this._isCameraEnabled) {
                    perspective = Math.tan(((90 - this._viewpointFov * 0.5) * this._doublePI) / 360) * this._height * 0.5;
                    var vX = viewpointTargetPoint[0] - viewpointPosition[0],
                        vY = viewpointTargetPoint[1] - viewpointPosition[1],
                        vZ = viewpointTargetPoint[2] - viewpointPosition[2],
                        yAngle = Math.atan2(-vX, -vZ),  // TODO: Review this
                        tmpZ,
                        xAngle;

                    tmpZ = vX * -Math.sin(-yAngle) + vZ * Math.cos(-yAngle);
                    xAngle = Math.atan2(-vY, -tmpZ);
                    this._element.style[this._transformPerspective]= perspective + "px";
                    cssText = "translate3d(0,0,";
                    cssText += perspective;
                    cssText += "px)rotateX(";
                    cssText += xAngle;
                    cssText += "rad)rotateY(";
                    cssText += (-yAngle);
                    cssText += "rad)";
                    cssText += "translate3d(";
                    cssText += (-viewpointPosition[0]);
                    cssText += "px,";
                    cssText += (-viewpointPosition[1]);
                    cssText += "px,";
                    cssText += (-viewpointPosition[2]);
                    cssText += "px)";

                    this._cameraElement.style[this._transform] = cssText;
                    this._element.classList.remove("camera-disabled");
                } else {
                    this._element.style[this._transformPerspective]= this._perspective + "px";
                    cssText = "translate3d(" ;
                    cssText += (0.5 * this._width - viewpointPosition[0]);
                    cssText += "px, ";
                    cssText += (0.5 * this._height - viewpointPosition[1]);
                    cssText += "px,0)";
                    this._cameraElement.style[this._transform] = cssText;
                    this._element.classList.add("camera-disabled");
                }
                this._isCameraUpdated = false;
            }
            if (_splinePaths.length) {
                for (i = 0; i < length; i++) {
                    offset = this.offset(visibleIndexes[i]);
                    iSplinePath = _splinePaths[offset.pathIndex];
                    indexTime = iSplinePath._convertSplineTimeToBezierIndexTime(offset.slideTime);
                    iteration = this._repetition._drawnIterations[i];
                    element = iteration.cachedFirstElement || iteration.firstElement;
                    if (indexTime !== null) {
                        if ((elementChildren = element.children[0])) {
                            if (element.classList.contains("selected")) {
                                elementChildren.classList.add("selected");
                            } else {
                                elementChildren.classList.remove("selected");
                            }
                            if (element.classList.contains("active")) {
                                elementChildren.classList.add("active");
                            } else {
                                elementChildren.classList.remove("active");
                            }
                        }
                        pos = iSplinePath.getPositionAtIndexTime(indexTime, this._sceneScale);
                        rotation = iSplinePath.getRotationAtIndexTime(indexTime);
                        cssText = this._transformCss;
                        cssText += ":translate3d(";
                        cssText += (((pos[0] * 100000) >> 0) * 0.00001);
                        cssText += "px,";
                        cssText += (((pos[1] * 100000) >> 0) * 0.00001);
                        cssText += "px,";
                        cssText += (((pos[2] * 100000) >> 0) * 0.00001) ;
                        cssText += "px)";
                        cssText += (rotation[2] ? "rotateZ(" + (((rotation[2] * 100000) >> 0) * 0.00001) + "rad)" : "");
                        cssText += (rotation[1] ? "rotateY(" + (((rotation[1] * 100000) >> 0) * 0.00001) + "rad)" : "");
                        cssText += (rotation[0] ? "rotateX(" + (((rotation[0] * 100000) >> 0) * 0.00001) + "rad)" : "");
                        cssText += ";";
                        cssText += iSplinePath.getStyleAtIndexTime(indexTime);

                        element.setAttribute("style",cssText);

                    } else {
                        element.setAttribute("style", "display:none");
                    }
                }
            } else {
                for (i = 0; i < length; i++) {
                    iteration = this._repetition._drawnIterations[i];
                    element = iteration.cachedFirstElement || iteration.firstElement;
                    element.setAttribute("style", "display:none");
                }
            }
            // Continue animation during elastic scrolling
            if (this._slideOffsetsLength) {
                this.needsDraw = true;
            }
            if (this._needsToCallDidTranslateEndDelegate) {
                this._needsToCallDidTranslateEndDelegate = false;
                this.callDelegateMethod("didTranslateEnd", this);
            }
        }
    },

    didDraw: {
        value: function () {
            if (!this.viewportHeight || !this.viewportWidth) {
                this.needsDraw = true;
            }
        }
    },

    // TODO doc
    /**
     */
    _updateLength: {
        value: function _updateLength() {
            if (this._paths) {
                var iPath,
                    pathsLength = this._paths.length,
                    iterations,
                    iLength,
                    maxLength = 0,
                    div, mod,
                    i;

                if (pathsLength > 0) {
                    mod = this._numberOfIterations % pathsLength;
                    div = (this._numberOfIterations - mod) / pathsLength;
                    for (i = 0; i < pathsLength; i++) {
                        iPath = this._paths[i];
                        iterations = div + ((i < mod) ? 1 : 0);
                        iLength = iterations - iPath.tailOffset + iPath.headOffset - 1;
                        if (iLength > maxLength) {
                            maxLength = iLength;
                        }
                    }
                    this.length = maxLength;
                }
                this.needsDraw = true;
            }
        }
    },

    /**
     * `numberOfIterations` represents the number of iterations that
     * would be visible without culling the ones that are outside the field of
     * view.  It is the same as the number of values from the content after
     * filters are applied by the content controller.
     *
     * The content length is managed by the flow.  Do not set this property.
     * It is however safe to read and  observe changes to the property.
     */
    _numberOfIterations: {
        value: 0
    },

    handle_numberOfIterationsChange: {
        value: function () {
            this._updateLength();
        }
    },

    content: {
        get: function () {
            return this.getPath("contentController.content");
        },
        set: function (content) {
            this.contentController = new RangeController().initWithContent(content);
        }
    },

    allowsMultipleSelection: {
        value: false
    },

    // TODO doc
    /**
     */
    contentController: {
        value: null
    },

    // TODO doc
    /**
     */
    isSelectionEnabled: {
        value: null
    },

    // TODO doc
    /**
     */
    selectedIndexes: {
        serializable: false,
        value: null
    },

    // TODO doc
    /**
     */
    activeIndexes: {
        serializable: false,
        value: null
    },

    observeProperty: {
        value: function (key, emit, scope) {
            return observeProperty(this, key, emit, scope);
        }
    },

    // TODO remove, will be obsoleted by inner template, provided we have a way
    // to redraft the innter template with a wrapper node.
    templateDidLoad: {
        value: function () {
            var self = this;
            // TODO consider a two-way binding on needsDraw
            this._repetition.willDraw = function () {
                self.needsDraw = true;
            };
        }
    },

    _length: {
        value: 0
    },

    /**
     * The number of visible iterations after frustum culling, which is
     * subject to variation depending on the scroll offset.
     *
     * <pre>
     * +----+ +----+ +----+ +----+ +----+
     * |    | |    | |    | |    | |    | length = 5
     * +----+ +----+ +----+ +----+ +----+
     * --+ +----+ +----+ +----+ +----+ +-
     *   | |    | |    | |    | |    | |  length = 6
     * --+ +----+ +----+ +----+ +----+ +-
     * </pre>
     */
    length: {
        get: function () {
            return this._length;
        },
        set: function (value) {
            if (value < 0) {
                this._length = 0;
            } else {
                this._length = value;
            }
        }
    },

    // TODO doc
    /*
     */
    _scroll: {
        value: 0
    },

    // TODO doc
    /*
     */
    _elasticScrollingRange: {
        value: 20
    },

    _hasElasticScrolling: {
        value: false
    },

    // TODO doc
    /**
     */
    hasElasticScrolling: {
        get: function () {
            return this._hasElasticScrolling;
        },
        set: function (value) {
            if (value) {
                this._hasElasticScrolling = true;
            } else {
                this._hasElasticScrolling = false;
            }
        }
    },

    // TODO doc
    /**
     */
    _slideOffsets: {
        value: null
    },

    // TODO doc
    /*
     */
    _slideOffsetsLength: {
        value: 0
    },

    // TODO doc
    /*
     */
    _maxSlideOffsetIndex: {
        value: -1
    },

    // TODO doc
    /*
     */
    _minSlideOffsetIndex: {
        value: 2e9
    },

    // TODO doc
    /*
     */
    _updateSlideOffset: {
        value: function (index, value) {
            var epsilon = 1e-4;

            if (index >= 0) {
                if ((value < -epsilon) || (value > epsilon)) {
                    if (typeof this._slideOffsets[index] === "undefined") {
                        this._slideOffsetsLength++;
                        if (index < this._minSlideOffsetIndex) {
                            this._minSlideOffsetIndex = index;
                        }
                        if (index > this._maxSlideOffsetIndex) {
                            this._maxSlideOffsetIndex = index;
                        }
                    }
                    this._slideOffsets[index] = value;
                } else {
                    this._removeSlideOffset(index);
                }
            }
        }
    },

    // TODO doc
    /*
     */
    _incrementSlideOffset: {
        value: function (index, value) {
            this._updateSlideOffset(index, this._getSlideOffset(index) + value);
        }
    },

    // TODO doc
    /*
     */
    _removeSlideOffset: {
        value: function (index) {
            if (typeof this._slideOffsets[index] !== "undefined") {
                var keys, i, integerKey, keysLength;

                delete this._slideOffsets[index];
                this._slideOffsetsLength--;
                if (index === this._minSlideOffsetIndex) {
                    keys = Object.keys(this._slideOffsets);
                    this._minSlideOffsetIndex = 2e9;
                    for (i = 0, keysLength = keys.length; i < keysLength; i++) {
                        integerKey = keys[i] | 0;
                        if (integerKey < this._minSlideOffsetIndex) {
                            this._minSlideOffsetIndex = integerKey;
                        }
                    }
                }
                if (index === this._maxSlideOffsetIndex) {
                    if (typeof keys === "undefined") {
                        keys = Object.keys(this._slideOffsets);
                        keysLength = keys.length;
                    }
                    this._maxSlideOffsetIndex = -1;
                    for (i = 0; i < keysLength; i++) {
                        integerKey = keys[i] | 0;
                        if (integerKey > this._maxSlideOffsetIndex) {
                            this._maxSlideOffsetIndex = integerKey;
                        }
                    }
                }
            }
        }
    },

    // TODO doc
    /*
     */
    _getSlideOffset: {
        value: function (index) {
            if (index < this._minSlideOffsetIndex) {
                if (this._minSlideOffsetIndex > this._draggedSlideIndex) {
                    index = this._draggedSlideIndex;
                } else {
                    index = this._minSlideOffsetIndex;
                }
            } else {
                if (index > this._maxSlideOffsetIndex) {
                    if (this._maxSlideOffsetIndex < this._draggedSlideIndex) {
                        index = this._draggedSlideIndex;
                    } else {
                        index = this._maxSlideOffsetIndex;
                    }
                }
            }
            if (typeof this._slideOffsets[index] !== "undefined") {
                return this._slideOffsets[index];
            } else {
                return 0;
            }
        }
    },

    // TODO doc
    /**
     */
    scroll: {
        get: function () {
            return this._scroll;
        },
        set: function (value) {
            if (value < 0) {
                value = 0;
            }
            if (value > this.length) {
                value = this.length;
            }
            if (this._hasElasticScrolling && (this._draggedSlideIndex !== null)) {
                var i,
                    n,
                    min = this._draggedSlideIndex - this._elasticScrollingRange,
                    max = this._draggedSlideIndex + this._elasticScrollingRange,
                    tmp,
                    j,
                    x;

                if (min > this._minSlideOffsetIndex) {
                    min = this._minSlideOffsetIndex;
                }
                if (max < this._maxSlideOffsetIndex) {
                    max = this._maxSlideOffsetIndex;
                }
                tmp = value - this._scroll;
                if (min < 0) {
                    min = 0;
                }
                for (i = min; i <= max; i++) {
                    if (i !== this._draggedSlideIndex) {
                        this._incrementSlideOffset(i, tmp);
                    } else {
                        this._removeSlideOffset(i);
                    }
                }
                this._scroll = value;
            } else {
                this._scroll = value;
            }

            this.needsDraw = true;
        }
    },

    previousStride: {
        value: function () {
            var currentPosition = Math.round(this.scroll / this.stride),
                moveTo = (currentPosition - 1) * this.stride;

            this.startScrollingIndexToOffset(0, -moveTo);
        }
    },

    nextStride: {
        value: function () {
            var currentPosition = Math.round(this.scroll / this.stride),
                moveTo = (currentPosition + 1) * this.stride;

            this.startScrollingIndexToOffset(0, -moveTo);
        }
    },

    _isInputEnabled: { // TODO: Replace by pointerBehavior
        value: true
    },

    // TODO doc
    /**
     */
    isInputEnabled: {
        get: function () {
            return this._isInputEnabled;
        },
        set: function (value) {
            if (value) {
                this._isInputEnabled = true;
            } else {
                this._isInputEnabled = false;
            }
        }
    },

    // TODO doc
    /**
     */
    _draggedSlideIndex: {
        value: 0
    },

    // TODO doc
    /**
     */
    draggedSlideIndex: {
        get: function () {
            return this._draggedSlideIndex;
        },
        set: function (value) {
            if (value !== this._draggedSlideIndex) {
                if (value !== null) {
                    var offset = this._getSlideOffset(value),
                        min = this._minSlideOffsetIndex,
                        max = this._maxSlideOffsetIndex,
                        i;

                    this._incrementSlideOffset(this._draggedSlideIndex, -offset);
                    for (i = min; i <= max; i++) {
                        if (i !== this._draggedSlideIndex) {
                            this._incrementSlideOffset(i, -offset);
                        }
                    }
                    this._removeSlideOffset(value);
                    this._scroll -= offset;
                    this._flowTranslateComposer._scroll = this._scroll;
                }
                this._draggedSlideIndex = value;
                this.needsDraw = true;
            }
        }
    },

    // TODO doc
    /**
     */
    _elasticScrollingSpeed: {
        value: 1
    },

    elasticScrollingSpeed: {
        get: function () {
            return this._elasticScrollingSpeed;
        },
        set: function (value) {
            this._elasticScrollingSpeed = parseFloat(value);
        }
    },

    // TODO doc
    /**
     */
    lastDrawTime: {
        value: null
    },

    // TODO doc
    /**
     */
    offset: {
        enumerable: false,
        value: function (slideIndex) {
            var pathsLength = this._paths.length,
                pathIndex = slideIndex % pathsLength,
                slideTime = Math.floor(slideIndex / pathsLength) - this._scroll + this._paths[pathIndex].headOffset;

            return {
                pathIndex: pathIndex,
                slideTime: slideTime + this._getSlideOffset(slideIndex)
            };
        }
    },

    serializeSelf: {
        value: function (serializer) {
            serializer.setAllValues();

            // TODO: we need a way to add nodes to the serialization... we only
            // have methods to serialize components.

            // HACK: we're only going to serialize components if their DOM
            // element is a direct child of the flow, since we don't have a way
            // to add elements to the serialization there's really no point in
            // doing anyelse reliably.
            var originalContent = this.originalContent;
            for (var i = 0, node; (node = originalContent[i]); i++) {
                if (node.component) {
                    serializer.addObject(node.component);
                }
            }
        }
    }
});

if (window.MontageElement) {
    MontageElement.define("montage-flow", Flow);
}
